# This file is part of the faebryk project
# SPDX-License-Identifier: MIT

import logging
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Sequence

import faebryk.library._F as F
from atopile.cli.logging_ import ALERT
from atopile.config import (
    BuildTargetConfig,
    ProjectConfig,
    UserConfigurationError,
    find_project_config_file,
)
from atopile.config import config as Gcfg
from atopile.errors import UserValueError
from atopile.layout import in_sub_pcb
from faebryk.core.module import Module
from faebryk.exporters.pcb.kicad.transformer import PCB_Transformer
from faebryk.libs.ato_part import AtoPart
from faebryk.libs.exceptions import UserResourceException, accumulate
from faebryk.libs.kicad.fileformats_common import C_xyr
from faebryk.libs.kicad.fileformats_latest import (
    C_kicad_footprint_file,
    C_kicad_fp_lib_table_file,
    C_kicad_model_file,
    C_kicad_pcb_file,
)
from faebryk.libs.kicad.fileformats_version import kicad_footprint_file
from faebryk.libs.kicad.ipc import opened_in_pcbnew
from faebryk.libs.picker.lcsc import (
    EasyEDA3DModel,
    EasyEDAAPIResponse,
    EasyEDAFootprint,
    EasyEDAPart,
    EasyEDASymbol,
    PickedPartLCSC,
)
from faebryk.libs.util import (
    KeyErrorNotFound,
    find,
    indented_container,
    md_list,
    once,
    path_replace,
    re_in,
    robustly_rm_dir,
    sanitize_filepath_part,
)

logger = logging.getLogger(__name__)


MANAGED_LIB_PREFIX = "atopile: part lib:"


class PartIsNotAutoGenerated(Exception):
    """
    Part exists but is not auto-generated.
    """

    def __init__(self, part: AtoPart):
        super().__init__(f"Part `{part.path}` is not purely auto-generated.")
        self.part = part


class PartLifecycle:
    """

    ```
    EasyEDA API => easyeda2kicad => picker/lcsc.py
        -> build/cache/easyeda/parts/<id>/<id>.json => picker/lcsc.py
        -> build/cache/easyeda/parts/<id>/*.kicad*|.step => picker/lcsc.py
        -> <component_lib>/parts/<mfr_pn>/*.kicad*|.step|.ato => transformer.py
        -> <layout_path>/<build>/<build>.kicad_pcb
    ```

    Notes:
     - `layout_path` determined by config.py (defaults to elec/layout)
     - `component_lib` determined by config.py (defaults to build/kicad/libs)
    """

    class EasyEDA_API:
        DELTA_REFRESH = timedelta(days=1)

        @property
        def _PATH(self) -> Path:
            return Gcfg.project.paths.build / "cache" / "parts" / "easyeda"

        def _get_part_path(self, partno: str) -> Path:
            return self._PATH.joinpath(partno) / f"{partno}.json"

        def _exists(self, partno: str) -> bool:
            return self._get_part_path(partno).exists()

        def shall_refresh(self, partno: str) -> bool:
            if not self._exists(partno):
                return True
            date_queried = self.load(partno).query_time
            return date_queried < datetime.now() - self.DELTA_REFRESH

        def ingest(self, partno: str, data: EasyEDAAPIResponse) -> EasyEDAAPIResponse:
            if self._exists(partno):
                existing = self.load(partno)
                # check whether footprint changed
                if existing.packageDetail.updateTime == data.packageDetail.updateTime:
                    return existing
                # TODO question user
                logger.warning(f"Updating cached {partno}")

            path = self._get_part_path(partno)
            data.dump(path)
            return data

        def load(self, partno: str) -> EasyEDAAPIResponse:
            return EasyEDAAPIResponse.load(self._get_part_path(partno))

    class Easyeda2Kicad:
        @property
        def _PATH(self) -> Path:
            return Gcfg.project.paths.build / "cache" / "parts" / "easyeda"

        def _get_part_path(self, partno: str) -> Path:
            return self._PATH / partno

        def get_fp_path(self, partno: str, footprint_name: str) -> Path:
            # public because needed by lcsc.py
            return self._get_part_path(partno) / f"{footprint_name}.kicad_mod"

        def _get_sym_path(self, partno: str, sym_name: str) -> Path:
            return self._get_part_path(partno) / f"{sym_name}.kicad_sym"

        def get_model_path(self, partno: str, model_name: str) -> Path:
            # public because needed by lcsc.py

            # TODO: Note: There are two names for models
            # 1. filename derived from footprint
            # 2. internal step file name
            # The only way to find out the internal is to download and read the step
            return self._get_part_path(partno) / f"{model_name}.step"

        def shall_refresh_model(self, part: EasyEDAPart) -> bool:
            # no model in api
            if not part.model and not part._pre_model:
                return False

            # TODO: consider timing out?
            if self.get_model_path(part.identifier, part.model_name).exists():
                return False

            return True

        def load_model(self, part: EasyEDAPart) -> EasyEDA3DModel:
            return EasyEDA3DModel.load(
                self.get_model_path(part.identifier, part.model_name)
            )

        def ingest_part(self, part: EasyEDAPart) -> EasyEDAPart:
            footprint = part.footprint
            fp_path = self.get_fp_path(part.identifier, part.footprint.base_name)
            if fp_path.exists():
                existing = EasyEDAFootprint.load(fp_path)
                if diff := existing.compare(footprint):
                    logger.warning(
                        f"Updating cached footprint {part.footprint.base_name}:"
                        f" {indented_container(diff, recursive=True)}"
                    )
                    footprint.dump(fp_path)
            else:
                footprint.dump(fp_path)

            if part.model or part._pre_model:
                model_path = self.get_model_path(part.identifier, part.model_name)
                if part.model:
                    part.model.dump(model_path)
                elif model_path.exists():
                    part.model = EasyEDA3DModel.load(model_path)

            # TODO not sure about name
            sym_name = part.symbol.kicad_symbol.name
            sym_path = self._get_sym_path(part.identifier, sym_name)
            if sym_path.exists():
                existing = EasyEDASymbol.load(sym_path)
                if diff := existing.compare(part.symbol):
                    logger.warning(
                        f"Updating cached symbol {sym_name}:"
                        f" {indented_container(diff, recursive=True)}"
                    )
                    part.symbol.dump(sym_path)
            else:
                part.symbol.dump(sym_path)

            return part

    class Library:
        @property
        def _PATH(self) -> Path:
            return Gcfg.project.paths.parts

        def _get_part_identifier(self, part: EasyEDAPart) -> str:
            sanitized = [sanitize_filepath_part(x) for x in part.mfn_pn]
            manufacturer, partno = sanitized
            if not manufacturer:
                manufacturer = "UNKNOWN"
            assert partno

            return f"{manufacturer}_{partno}"

        def _get_part_path(self, part: EasyEDAPart) -> Path:
            return self._PATH / self._get_part_identifier(part)

        def fp_table(
            self, build: BuildTargetConfig
        ) -> tuple[Path, C_kicad_fp_lib_table_file]:
            fp_table_path = build.paths.fp_lib_table

            try:
                old_fp_table = C_kicad_fp_lib_table_file.loads(fp_table_path)
                for lib in old_fp_table.fp_lib_table.libs.values():
                    if not lib.descr.startswith(MANAGED_LIB_PREFIX):
                        logger.warning(
                            f"Removing unmanaged footprint library `{lib.name}`"
                        )
            except FileNotFoundError:
                # just generate if missing (e.g. on first run)
                pass

            # recreate table to ensure sync
            fp_table = C_kicad_fp_lib_table_file.skeleton()
            # load all existing parts into new table
            for part_dir in sorted(
                Gcfg.project.paths.parts.iterdir(), key=lambda x: x.name
            ):
                part_identifier = part_dir.name
                if not part_dir.is_dir():
                    continue
                if not (part_dir / (part_identifier + ".ato")).is_file():
                    continue
                self.__insert_fp_lib(part_identifier, fp_table)

            return fp_table_path, fp_table

        def _insert_fp_lib(self, lib_name: str, fppath: Path | None = None):
            for build in Gcfg.project.builds.values():
                fp_table_path, fp_table = self.fp_table(build)
                self.__insert_fp_lib(lib_name, fp_table, fppath)
                fp_table.dumps(fp_table_path)

        def __insert_fp_lib(
            self,
            lib_name: str,
            fp_table: C_kicad_fp_lib_table_file,
            fppath: Path | None = None,
        ):
            fppath = fppath or self._PATH / lib_name

            if not fppath.resolve().exists():
                raise FileNotFoundError(f"Footprint does not exist: {fppath.resolve()}")

            fpuri = Gcfg.project.get_relative_to_kicad_project(fppath.resolve())

            if lib_name in fp_table.fp_lib_table.libs:
                if fp_table.fp_lib_table.libs[lib_name].uri != fpuri:
                    # TODO better exception
                    raise Exception(
                        f"Footprint library {lib_name} already exists at different"
                        f" location: {fp_table.fp_lib_table.libs[lib_name].uri} != "
                        f"{fpuri}. Manual ingestion required."
                    )
                return fp_table

            lib = C_kicad_fp_lib_table_file.C_fp_lib_table.C_lib(
                name=lib_name,
                type="KiCad",
                uri=fpuri,
                options="",
                descr=f"{MANAGED_LIB_PREFIX} {lib_name}",
            )
            fp_table.fp_lib_table.libs[lib_name] = lib
            # TODO move somewhere else
            if not getattr(self, "_printed_alert", False):
                # check if any running pcbnew instances
                # TODO: actually pass pcb
                if opened_in_pcbnew(pcb_path=None):
                    logger.log(ALERT, "pcbnew restart required (updated fp-lib-table)")
                    self._printed_alert = True

            return fp_table

        def ingest_part(self, part: AtoPart) -> AtoPart:
            from faebryk.library.is_auto_generated import _FileManuallyModified

            def _filter_datasheet_downgrades(diff: dict) -> dict:
                """
                EasyEDA ingestion only sometimes returns a datasheet. In the case where
                we did have one previously, filter that update out of the diff so we
                don't then remove it from the part definition.
                """

                if (datasheet := diff.get(".datasheet")) is not None:
                    before, after = datasheet
                    if before is not None and after is None:
                        del diff[".datasheet"]

                return diff

            if part.path.exists():
                try:
                    existing = AtoPart.load(part.path)
                except _FileManuallyModified as ex:
                    raise UserValueError(
                        f"Part `{part.path}` has been manually modified ({ex}). Please"
                        " revert the changes or remove the `is_auto_generated` trait."
                    ) from ex
                if not existing.auto_generated:
                    raise PartIsNotAutoGenerated(existing)

                diff = existing.compare(part)
                for filter_func in [
                    _filter_datasheet_downgrades,
                ]:
                    diff = filter_func(diff)

                if diff:
                    str_diff = str(diff)
                    if len(str_diff) > 1000:
                        str_diff = "Too long to show..."
                    else:
                        str_diff = indented_container(diff, recursive=True)
                    logger.warning(
                        f"Updating library part {part.identifier}: {str_diff}"
                    )
                    robustly_rm_dir(part.path)
                    part.dump()
            else:
                part.dump()

            self._insert_fp_lib(part.path.name)

            return part

        def ingest_part_from_easyeda(self, epart: EasyEDAPart) -> AtoPart:
            out_path = self._get_part_path(epart)
            identifier = self._get_part_identifier(epart)

            # cache
            # we assume that during runtime identical part ids are identical
            # only used for performance atm
            if not hasattr(self, "_cache_ingest_part_from_easyeda"):
                self._cache_ingest_part_from_easyeda = dict[str, AtoPart]()
            if identifier in self._cache_ingest_part_from_easyeda:
                return self._cache_ingest_part_from_easyeda[identifier]

            ato_part = AtoPart(
                identifier=identifier,
                mfn=epart.mfn_pn,
                path=out_path,
                fp=epart.footprint.footprint,
                symbol=epart.symbol.symbol,
                model=C_kicad_model_file.loads(epart.model.step)
                if epart.model
                else None,
                auto_generated=AtoPart.AutoGenerated(
                    source=f"easyeda:{epart.identifier}",
                ),
                datasheet=epart.datasheet_url,
                docstring=epart.description,
                pick_part=PickedPartLCSC(
                    manufacturer=epart.mfn_pn[0],
                    partno=epart.mfn_pn[1],
                    supplier_partno=epart.lcsc_id,
                ),
            )
            out = self.ingest_part(ato_part)

            self._cache_ingest_part_from_easyeda[identifier] = out
            return out

        def get_part_from_footprint_identifier(
            self, identifier: str, component: Module
        ) -> Path:
            # TODO this is old code, avoid reading fp-lib-table
            fp_lib_path = Gcfg.build.paths.fp_lib_table

            class LibNotInTable(Exception):
                def __init__(
                    self, *args: object, lib_id: str, lib_table_path: Path
                ) -> None:
                    super().__init__(
                        f"Footprint library {lib_id} not found in table "
                        f"{lib_table_path}",
                        *args,
                    )
                    self.lib_id = lib_id
                    self.lib_table_path = lib_table_path

            def _find_footprint(
                lib_tables: Sequence[os.PathLike], lib_id: str
            ) -> C_kicad_fp_lib_table_file.C_fp_lib_table.C_lib:
                lib_tables = [Path(lib_table) for lib_table in lib_tables]

                err_accumulator = accumulate(LibNotInTable, FileNotFoundError)

                for lib_table_path in lib_tables:
                    with err_accumulator.collect():
                        lib_table = C_kicad_fp_lib_table_file.loads(lib_table_path)
                        try:
                            return find(
                                lib_table.fp_lib_table.libs.values(),
                                lambda x: x.name == lib_id,
                            )
                        except KeyErrorNotFound as ex:
                            raise LibNotInTable(
                                lib_id=lib_id, lib_table_path=lib_table_path
                            ) from ex

                if ex := err_accumulator.get_exception():
                    raise ex

                raise ValueError("No footprint libraries provided")

            lib_id, _ = identifier.split(":")
            try:
                lib = _find_footprint([fp_lib_path], lib_id)
            except* (LibNotInTable, FileNotFoundError):
                from atopile.front_end import from_dsl  # TODO: F.is_from_dsl

                if (
                    (is_atomic_part_ := component.try_get_trait(F.is_atomic_part))
                    is not None
                    and (from_dsl_ := is_atomic_part_.try_get_trait(from_dsl))
                    is not None
                    and from_dsl_.src_file is not None
                ):
                    # insert footprint on first use from dependency package
                    try:
                        self._insert_fp_lib(lib_id, from_dsl_.src_file.parent)
                    except FileNotFoundError:
                        raise

                    lib = _find_footprint([fp_lib_path], lib_id)
                else:
                    raise

            part_path = Path(
                str(lib.uri).replace(
                    "${KIPRJMOD}", str(Gcfg.build.paths.fp_lib_table.parent)
                )
            )

            return part_path

        @staticmethod
        def _get_project_from_part_path(part_path: Path) -> Path | None:
            # TODO: hate this
            manifest_path = find_project_config_file(part_path)
            if manifest_path is None:
                return None
            return manifest_path.parent

        def _fix_package_footprint_model(
            self, fp: C_kicad_footprint_file, fp_path: Path
        ):
            layout_path = Gcfg.build.paths.layout.parent
            project_path = self._get_project_from_part_path(fp_path)
            if project_path is None:
                raise ValueError(f"No project path found for {fp_path}")
            package_project_config = ProjectConfig.from_path(project_path)
            if not package_project_config:
                raise ValueError(
                    f"No project config found for {fp_path} at {project_path}"
                )
            inner_layout_path = package_project_config.paths.layout
            rel_path = inner_layout_path.relative_to(layout_path, walk_up=True)
            for m in fp.footprint.models:
                # "${KIPRJMOD}/
                #   ../../parts/<part>/<model>"
                # ->
                # "${KIPRJMOD}/../../.ato/modules/<package>/<layout_dir>/
                #   ../parts/<part>/<model>"
                m.path = path_replace(
                    m.path,
                    Path("${KIPRJMOD}") / "..",
                    Path("${KIPRJMOD}") / rel_path,
                )

        def get_footprint_from_identifier(
            self, identifier: str, component: Module
        ) -> tuple[Path, C_kicad_footprint_file]:
            lib_id, fp_name = identifier.split(":")
            part_path = self.get_part_from_footprint_identifier(identifier, component)
            fp_path = part_path / f"{fp_name}.kicad_mod"
            try:
                fp = kicad_footprint_file(fp_path)

                # TODO: associate source project with component, so all that's needed
                # here is to substitute ${KIPRJMOD} + rel_path for ${KIPRJMOD}
                if ".ato" in fp_path.parts:
                    self._fix_package_footprint_model(fp, fp_path)

                return fp_path, fp
            except FileNotFoundError as ex:
                raise UserResourceException(
                    f"Footprint `{fp_name}` doesn't exist in library `{lib_id}`"
                ) from ex

    class PCB:
        def __init__(self) -> None:
            pass

        def ingest_footprint(
            self,
            transformer: PCB_Transformer,
            component: Module,
            logger: logging.Logger,
            insert_point: C_xyr | None = None,
        ) -> tuple[C_kicad_pcb_file.C_kicad_pcb.C_pcb_footprint, bool]:
            # TODO this is old code taken from PCB_Transformer
            # need to decouple some actions from here
            # e.g insert point is not really at the right place here

            # TODO also implement the ingest logic correctly
            # if the footprint is already on the pcb, we should run our typical
            # resolution flow

            lifecycle = PartLifecycle.singleton()

            f_fp = component.get_trait(F.has_footprint).get_footprint()

            # At this point, all footprints MUST have a KiCAD identifier
            fp_id = f_fp.get_trait(F.has_kicad_footprint).get_kicad_footprint()

            # This is the component which is being stuck on the board
            address = component.get_full_name()

            # All modules MUST have a designator by this point
            ref = component.get_trait(F.has_designator).get_designator()

            ## Update existing footprint
            if pcb_fp_t := f_fp.try_get_trait(
                PCB_Transformer.has_linked_kicad_footprint
            ):
                pcb_fp = pcb_fp_t.get_fp()

                # TODO: this is where I have to implement the footprint override
                if True or fp_id != pcb_fp.name:
                    # Copy the data structure so if we later mutate it we don't
                    # end up w/ those changes everywhere
                    _, lib_fp = lifecycle.library.get_footprint_from_identifier(
                        fp_id, component
                    )
                    if existing_hash_prop := pcb_fp.propertys.get(
                        PCB_Transformer._FP_LIB_HASH
                    ):
                        existing_hash = existing_hash_prop.value
                    else:
                        existing_hash = None

                    # If the hash never existed, or it's changed then update the
                    # footprint
                    if (
                        existing_hash is None
                        or existing_hash
                        != PCB_Transformer._hash_lib_fp(lib_fp.footprint)
                    ):
                        logger.info(
                            f"Updating `{pcb_fp.name}`->`{fp_id}` on"
                            f" `{address}` ({ref})",
                            extra={"markdown": True},
                        )
                        # We need to manually override the name because the
                        # footprint's data could've ultimately come from anywhere
                        lib_fp.footprint.name = fp_id
                        transformer.update_footprint_from_lib(pcb_fp, lib_fp.footprint)
                        transformer.bind_footprint(pcb_fp, component)
                new_fp = False

            ## Add new footprint
            else:
                # Components and footprints MUST have the same linking by this point
                # This should be enforced through attach, and bind_footprint
                assert not component.has_trait(
                    PCB_Transformer.has_linked_kicad_footprint
                )

                logger.info(f"Adding `{fp_id}` as `{address}` ({ref})")
                # Copy the data structure so if we later mutate it we don't
                # end up w/ those changes everywhere
                _, lib_fp = lifecycle.library.get_footprint_from_identifier(
                    fp_id, component
                )
                # We need to manually override the name because the
                # footprint's data could've ultimately come from anywhere
                lib_fp.footprint.name = fp_id
                pcb_fp = transformer.insert_footprint(lib_fp.footprint, insert_point)
                transformer.bind_footprint(pcb_fp, component)
                new_fp = True

            def _get_prop_uuid(name: str) -> str | None:
                if name in pcb_fp.propertys:
                    return pcb_fp.propertys[name].uuid
                return None

            ## Apply propertys, Reference and atopile_address
            property_values = {}

            # Take any descriptive properties defined on the component
            if c_props_t := component.try_get_trait(F.has_descriptive_properties):
                for prop_name, prop_value in c_props_t.get_properties().items():
                    if re_in(
                        prop_name,
                        PCB_Transformer.INCLUDE_DESCRIPTIVE_PROPERTIES_FROM_PCB(),
                    ):
                        property_values[prop_name] = prop_value

            if c_props_t := component.try_get_trait(F.has_datasheet):
                property_values["Datasheet"] = c_props_t.get_datasheet()

            property_values["Reference"] = ref

            if value_t := component.try_get_trait(F.has_simple_value_representation):
                property_values["Value"] = value_t.get_value()
            else:
                property_values["Value"] = ""

            property_values["atopile_address"] = component.get_full_name()
            if sub_pcb_t := component.try_get_trait(in_sub_pcb):
                property_values["atopile_subaddresses"] = (
                    "["
                    + ", ".join(
                        sorted(
                            subaddress.serialize() for subaddress in sub_pcb_t.addresses
                        )
                    )
                    + "]"
                )

            for prop_name, prop_value in property_values.items():
                ### Get old property value, representing non-existent properties as None
                ### If the property value has changed, update it
                if prop := pcb_fp.propertys.get(prop_name):
                    if prop_value != prop.value:
                        logger.info(
                            f"Updating `{prop_name}`->`{prop_value}` on"
                            f" `{address}` ({ref})",
                            extra={"markdown": True},
                        )
                        pcb_fp.propertys[prop_name].value = prop_value

                ### If it's a new property, add it
                else:
                    logger.info(
                        f"Adding `{prop_name}`=`{prop_value}` to `{address}` ({ref})",
                        extra={"markdown": True},
                    )
                    pcb_fp.propertys[prop_name] = transformer._make_fp_property(
                        property_name=prop_name,
                        layer="User.9",
                        value=prop_value,
                        uuid=_get_prop_uuid(prop_name)
                        or PCB_Transformer.gen_uuid(mark=True),
                    )

            return pcb_fp, new_fp

    def __init__(self):
        self.easyeda_api = self.EasyEDA_API()
        self.easyeda2kicad = self.Easyeda2Kicad()
        self.library = self.Library()
        self.pcb = self.PCB()

        self._delete_deprecated_cache()

    def _delete_deprecated_cache(self):
        cfg_paths = Gcfg.project.paths
        build = Gcfg.project.paths.build

        old_fps = cfg_paths.root / "elec" / "footprints"
        old_ecache = build / "cache" / "easyeda"
        old_kifps = build / "kicad" / "libs" / "footprints" / "lcsc.pretty"
        old_ki3d = build / "kicad" / "libs" / "lcsc.3dshapes"

        if old_fps.is_dir() and (fps := list(old_fps.rglob("*.kicad_mod"))):
            raise UserConfigurationError(
                "The `footprints` directory is deprecated and not empty. "
                "Please transition your manual footprints to atomic parts."
                f"\n{md_list(fps)}"
            )

        for path in [old_ecache, old_kifps, old_ki3d]:
            if path.exists():
                logger.warning(f"Deleting deprecated cache {path}")
                robustly_rm_dir(path)

    @classmethod
    @once
    def singleton(cls) -> "PartLifecycle":
        return cls()
